Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

9장. 함수

지금까지는 모든 코드를 main 안에 적었다. 프로그램이 조금만 커져도 이 방식은 빠르게 무너진다. 같은 일을 여러 번 적어야 하고, 한 함수가 너무 많은 일을 떠안게 된다.

해결책은 단순하다. 같은 일을 묶어 이름을 붙이는 것. 이것이 함수 다.

이 장의 목표:

  • 함수를 정의하고 호출할 수 있다
  • 매개변수와 반환값을 다양한 형태로 다룰 수 있다
  • 다중 반환값과 명명된 반환값을 안다
  • 가변 인자를 사용할 수 있다
  • defer 로 정리 작업을 안전하게 처리한다
  • 익명 함수와 클로저를 이해한다

9.1 함수 정의와 호출

가장 기본적인 모양은 이렇다.

func 이름(매개변수) 반환타입 {
    // 본문
}

작은 예제부터 본다.

package main

import "fmt"

func greet() {
    fmt.Println("Hello!")
}

func main() {
    greet()
    greet()
}

실행 결과:

Hello!
Hello!
  • func 는 함수를 시작하는 키워드
  • 함수 이름은 변수 이름 규칙과 같다
  • () 안에 매개변수를 적는다 (없으면 비워 둔다)
  • 매개변수 뒤에 반환 타입을 적는다 (없으면 생략)
  • 중괄호 위치는 3장에서 본 규칙 그대로

매개변수가 없는 함수

greet 처럼 괄호 안을 비워 두면 된다.

반환값이 없는 함수

반환 타입을 적지 않으면 아무것도 반환하지 않는다. 이런 함수도 안에서 return 을 써서 일찍 빠져나올 수 있다.

func warn(msg string) {
    if msg == "" {
        return
    }
    fmt.Println("경고:", msg)
}

9.2 매개변수와 반환값

값을 받고 값을 돌려주는 함수가 가장 흔하다.

func square(n int) int {
    return n * n
}

func main() {
    fmt.Println(square(4)) // 16
}
  • n int 는 “이름이 n 이고 타입은 int 인 매개변수”
  • 마지막 int 는 반환 타입
  • 함수 본문에서 return 으로 값을 돌려준다

같은 타입 매개변수 묶기

매개변수가 여러 개고 타입이 같다면 마지막 한 번만 타입을 적어도 된다.

// 매번 적기
func add(a int, b int) int {
    return a + b
}

// 한 번만 적기 (같은 결과)
func add(a, b int) int {
    return a + b
}

세 개 이상도 마찬가지다.

func clamp(x, lo, hi int) int {
    if x < lo {
        return lo
    }
    if x > hi {
        return hi
    }
    return x
}

타입이 섞여 있다면 그룹별로 묶는다.

func repeat(s string, n int) string {
    // ...
}

func mix(a, b int, c, d string) {
    // ...
}

9.3 다중 반환값

Go 함수는 값을 여러 개 동시에 돌려줄 수 있다. 다른 많은 언어와 구별되는 특징이다.

func swap(a, b int) (int, int) {
    return b, a
}

func main() {
    x, y := swap(1, 2)
    fmt.Println(x, y) // 2 1
}

반환 타입을 괄호로 묶고, return 도 콤마로 여러 값을 돌려준다. 호출 쪽은 받는 변수 개수를 정확히 맞춰야 한다.

결과와 에러를 함께 돌려주기

다중 반환의 가장 흔한 쓰임은 “결과 + 성공 여부” 또는 “결과 + 에러” 다.

func divide(a, b int) (int, bool) {
    if b == 0 {
        return 0, false
    }
    return a / b, true
}

func main() {
    q, ok := divide(10, 3)
    if ok {
        fmt.Println("몫:", q)
    } else {
        fmt.Println("0 으로 나눌 수 없음")
    }
}

Go 표준 라이브러리는 이 패턴을 폭넓게 쓴다. 정식 에러 처리는 error 인터페이스로 한다. 이 부분은 21장에서 자세히 다룬다.

blank identifier _

여러 값 중 일부만 필요하다면 나머지를 _ (언더스코어) 로 받아 버린다.

_, ok := divide(10, 0)
if !ok {
    fmt.Println("실패")
}

_ 는 “이 값을 안 쓰겠다” 는 명시다. Go 는 선언만 하고 안 쓰는 변수를 컴파일 에러로 처리한다. _ 는 그 규칙을 우회하는 공식 도구다.


9.4 명명된 반환값 (named return)

반환 타입에 이름을 미리 붙일 수도 있다.

func divmod(a, b int) (q, r int) {
    q = a / b
    r = a % b
    return
}
  • 반환 타입 자리에 q, r int 처럼 이름과 타입을 같이 적었다
  • 함수가 시작될 때 q, r 은 제로값 (0) 으로 자동 선언된다
  • 마지막의 return 은 값을 적지 않아도 q, r 의 현재 값을 돌려준다. 이걸 naked return 이라 부른다

호출 쪽은 일반 다중 반환과 똑같이 받는다.

q, r := divmod(17, 5)
fmt.Println(q, r) // 3 2

언제 좋고 언제 나쁜가

명명된 반환값의 장점:

  • 함수 시그니처만 봐도 반환의 의미를 알 수 있다
  • 짧은 함수에서 의도를 분명히 드러낸다

단점:

  • 함수가 길어지면 어디서 값이 바뀌는지 추적이 어렵다
  • naked return 은 함수 끝에서 어떤 값이 나가는지 한눈에 안 보인다

짧고 의미가 분명한 함수에서만 쓴다. 본문이 길어지면 그냥 일반 다중 반환을 쓰는 편이 안전하다.


9.5 가변 인자 (variadic)

같은 타입의 인자를 임의 개수 받고 싶을 때 쓴다. 타입 앞에 점 세 개 ... 를 붙이면 된다.

func sum(nums ...int) int {
    total := 0
    for _, n := range nums {
        total += n
    }
    return total
}

func main() {
    fmt.Println(sum())             // 0
    fmt.Println(sum(1, 2, 3))      // 6
    fmt.Println(sum(10, 20, 30, 40)) // 100
}

함수 내부에서 nums 는 슬라이스([]int) 처럼 다룬다. range 로 순회할 수 있다.

슬라이스는 11장에서 본격적으로 다룬다. 지금은 “여러 값을 한 변수로 받는다” 정도만 알면 충분하다.

슬라이스 펼쳐 넘기기

이미 슬라이스를 가지고 있는데 가변 인자 함수에 그걸 넘기고 싶다면 호출할 때 ... 를 변수 뒤에 붙인다.

xs := []int{1, 2, 3, 4}
fmt.Println(sum(xs...)) // 10

xs... 가 없으면 컴파일 에러가 난다. Go 는 슬라이스를 자동으로 풀어 주지 않는다.

가변 인자는 마지막에 하나만

함수 시그니처에서 가변 인자는 가장 마지막 매개변수여야 하며, 하나만 둘 수 있다.

// OK
func f(prefix string, nums ...int) {}

// 컴파일 에러
func g(nums ...int, suffix string) {}

9.6 defer

defer 는 어떤 호출을 지금 등록하고 함수가 끝날 때 실행 시킨다.

func main() {
    defer fmt.Println("끝")
    fmt.Println("시작")
    fmt.Println("작업 중")
}

실행 결과:

시작
작업 중
끝

defer 가 가장 위에 있지만 출력은 마지막에 나온다. main 이 반환되기 직전에 실행되기 때문이다.

어디에 쓰나

가장 흔한 용도는 “정리(cleanup) 작업” 이다.

  • 파일 열고 닫기
  • 락 잡고 풀기
  • 네트워크 연결 열고 닫기
  • 자원 빌리고 반납하기

이런 작업은 “여는 부분” 바로 옆에 “닫는 부분” 을 defer 로 같이 적는다. 중간에 어떤 분기로 빠지든 함수만 끝나면 반드시 정리가 호출된다.

func work() {
    f := openFile()
    defer closeFile(f)  // 함수 끝에서 무조건 닫힘

    // 중간에 return, 에러로 빠져도 closeFile 은 호출된다
    process(f)
}

실제 파일 닫기는 29장에서, 락 해제는 23장에서 다시 만난다. 여기서는 defer 의 동작 원리만 익혀 두자.

LIFO 순서

여러 defer 가 쌓이면 가장 나중에 등록된 것부터 실행된다. 스택 구조(LIFO)다.

func main() {
    defer fmt.Println("1")
    defer fmt.Println("2")
    defer fmt.Println("3")
}

실행 결과:

3
2
1

여러 자원을 순서대로 열었을 때 역순으로 닫는 것이 자연스럽다는 점과 맞아 떨어진다.

평가 시점은 등록할 때, 실행은 함수 끝

defer 가 받는 함수 호출의 인자defer 를 만나는 그 순간 평가된다. 실제 호출만 함수 끝으로 미뤄진다.

func main() {
    x := 10
    defer fmt.Println("x =", x) // 여기서 x 가 10 으로 캡처됨
    x = 99
}

실행 결과:

x = 10

x 가 99 로 바뀌었지만, 출력은 defer 가 등록되던 시점의 10 이다. “인자는 그 자리에서 굳고, 실행만 미뤄진다” 고 기억하면 된다.


9.7 익명 함수와 클로저

함수에 꼭 이름을 붙여야 하는 것은 아니다. 이름 없이 그 자리에 함수를 만들어 바로 쓰는 것을 익명 함수 라 부른다.

정의 후 즉시 호출

func main() {
    func() {
        fmt.Println("이름 없이 호출")
    }()
}

마지막의 () 가 호출 부분이다. 정의하자마자 부른 것이다.

변수에 함수를 담기

함수는 그 자체가 하나의 값이다. 변수에 넣어 둘 수 있고, 다른 함수에 넘길 수도 있다.

func main() {
    add := func(a, b int) int {
        return a + b
    }

    fmt.Println(add(3, 4)) // 7
}

add 의 타입은 func(int, int) int 다. 이런 함수 타입을 매개변수로 받는 함수도 만들 수 있다.

func apply(f func(int) int, x int) int {
    return f(x)
}

func main() {
    double := func(n int) int { return n * 2 }
    fmt.Println(apply(double, 5)) // 10
}

클로저: 바깥 변수를 캡처한다

익명 함수가 자신을 둘러싼 함수의 변수를 계속 들고 있을 수 있다. 이렇게 바깥 변수를 붙잡고 있는 함수를 클로저(closure) 라 부른다.

간단한 카운터를 만들어 본다.

func makeCounter() func() int {
    count := 0
    return func() int {
        count++
        return count
    }
}

func main() {
    c := makeCounter()
    fmt.Println(c()) // 1
    fmt.Println(c()) // 2
    fmt.Println(c()) // 3
}

makeCounter 의 지역 변수 count 는 보통이라면 함수가 끝나는 순간 사라진다. 하지만 안에서 만든 익명 함수가 count 를 잡고 있어서 함수가 반환된 뒤에도 살아남는다.

호출할 때마다 같은 count 가 1 씩 증가한다.

각 클로저는 자기 변수를 가진다

makeCounter 를 다시 부르면 완전히 새로운 count 가 만들어진다.

c1 := makeCounter()
c2 := makeCounter()

fmt.Println(c1()) // 1
fmt.Println(c1()) // 2
fmt.Println(c2()) // 1  (c2 의 count 는 따로)
fmt.Println(c1()) // 3

클로저는 “함수 + 캡처한 변수” 의 묶음이다. 같은 함수를 두 번 호출해 만든 두 클로저는 서로 다른 환경을 가진다.

클로저는 강력하지만, 어떤 변수를 캡처했는지를 머릿속에 그리고 있어야 한다. 특히 for 루프 안에서 클로저를 만들 때 함정이 있다. 동시성 코드에서도 자주 문제가 되므로 22장에서 다시 정리한다.


9.8 정리

이 장에서 살펴본 내용:

  • 함수는 func 이름(매개변수) 반환타입 { ... }
  • 같은 타입 매개변수는 묶어서 한 번만 타입 표기 가능
  • 다중 반환값으로 결과와 상태를 함께 돌려준다
  • _ 로 필요 없는 반환값을 무시한다
  • 명명된 반환값과 naked return 은 짧은 함수에 한정
  • 가변 인자 ...T 와 슬라이스 펼치기 xs...
  • defer 는 함수 종료 시 실행, LIFO, 인자는 등록 시점에 평가
  • 함수도 값이다. 익명 함수와 클로저로 동작을 변수처럼 다룬다

함수를 자유롭게 다루게 됐으니 이제 다음 질문이 자연스럽다. “여기 선언한 변수는 어디까지 살아 있는가?”

다음 장은 변수의 범위(scope)다. 패키지 / 함수 / 블록 세 단계의 범위, 변수 가리기(shadowing) 와 := 의 함정, 그리고 전역 변수를 자제해야 하는 이유까지 다룬다.